package org.openstreetmap.gui.joust;

//License: GPL. Copyright 2008 by Jan Peter Stotz

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;
import java.nio.charset.Charset;
import java.util.logging.Level;
import java.util.logging.Logger;

import org.openstreetmap.gui.joust.interfaces.TileCache;
import org.openstreetmap.gui.joust.interfaces.TileLoader;
import org.openstreetmap.gui.joust.interfaces.TileLoaderListener;
import org.openstreetmap.gui.joust.interfaces.TileSource;
import org.openstreetmap.gui.joust.interfaces.TileSource.TileUpdate;

/**
 * A {@link TileLoader} implementation that loads tiles from OSM via HTTP and
 * saves all loaded files in a directory located in the the temporary directory.
 * If a tile is present in this file cache it will not be loaded from OSM again.
 * 
 * @author Jan Peter Stotz
 * @author Stefan Zeller
 */
public class OsmFileCacheTileLoader extends OsmTileLoader {

    private static final Logger log = Logger.getLogger(OsmFileCacheTileLoader.class.getName());

    private static final String ETAG_FILE_EXT = ".etag";

    private static final Charset ETAG_CHARSET = Charset.forName("UTF-8");

    public static final long FILE_AGE_ONE_DAY = 1000 * 60 * 60 * 24;
    public static final long FILE_AGE_ONE_WEEK = FILE_AGE_ONE_DAY * 7;

    protected String cacheDirBase;

    protected long maxCacheFileAge = FILE_AGE_ONE_WEEK;
    protected long recheckAfter = FILE_AGE_ONE_DAY;

    /**
     * Create a OSMFileCacheTileLoader with given cache directory.
     * If cacheDir is <code>null</code> the system property temp dir
     * is used. If not set an IOException will be thrown.
     * @param map
     * @param cacheDir
     */
    public OsmFileCacheTileLoader(TileLoaderListener map, File cacheDir) throws SecurityException {
        super(map);
        String tempDir = null;
        String userName = System.getProperty("user.name");
        try {
            tempDir = System.getProperty("java.io.tmpdir");
        } catch (SecurityException e) {
            log.log(Level.WARNING,
                    "Failed to access system property ''java.io.tmpdir'' for security reasons. Exception was: "
                            + e.toString());
            throw e; // rethrow
        }
        try {
            if (cacheDir == null) {
                if (tempDir == null)
                    throw new IOException("No temp directory set");
                String subDirName = "JMapViewerTiles";
                // On Linux/Unix systems we do not have a per user tmp directory. 
                // Therefore we add the user name for getting a unique dir name.  
                if (userName != null && userName.length() > 0)
                    subDirName += "_" + userName;
                cacheDir = new File(tempDir, subDirName);
            }
            log.finest("Tile cache directory: " + cacheDir);
            if (!cacheDir.exists() && !cacheDir.mkdirs())
                throw new IOException();
            cacheDirBase = cacheDir.getAbsolutePath();
        } catch (Exception e) {
            cacheDirBase = "tiles";
        }
    }

    /**
     * Create a OSMFileCacheTileLoader with system property temp dir.
     * If not set an IOException will be thrown.
     * @param map
     */
    public OsmFileCacheTileLoader(TileLoaderListener map) throws SecurityException {
        this(map, null);
    }

    @Override
    public Runnable createTileLoaderJob(final TileSource source, final int tilex, final int tiley, final int zoom) {
        return new FileLoadJob(source, tilex, tiley, zoom);
    }

    protected class FileLoadJob implements Runnable {
        InputStream input = null;

        int tilex, tiley, zoom;
        Tile tile;
        TileSource source;
        File tileCacheDir;
        File tileFile = null;
        long fileAge = 0;
        boolean fileTilePainted = false;

        public FileLoadJob(TileSource source, int tilex, int tiley, int zoom) {
            super();
            this.source = source;
            this.tilex = tilex;
            this.tiley = tiley;
            this.zoom = zoom;
        }

        public void run() {
            TileCache cache = listener.getTileCache();
            synchronized (cache) {
                tile = cache.getTile(source, tilex, tiley, zoom);
                if (tile == null || tile.isLoaded() || tile.loading)
                    return;
                tile.loading = true;
            }
            tileCacheDir = new File(cacheDirBase, source.getName());
            if (!tileCacheDir.exists()) {
                tileCacheDir.mkdirs();
            }
            if (loadTileFromFile())
                return;
            if (fileTilePainted) {
                Runnable job = new Runnable() {

                    public void run() {
                        loadOrUpdateTile();
                    }
                };
                JobDispatcher.getInstance().addJob(job);
            } else {
                loadOrUpdateTile();
            }
        }

        protected void loadOrUpdateTile() {

            try {
                // log.finest("Loading tile from OSM: " + tile);
                HttpURLConnection urlConn = loadTileFromOsm(tile);
                if (tileFile != null) {
                    switch (source.getTileUpdate()) {
                    case IfModifiedSince:
                        urlConn.setIfModifiedSince(fileAge);
                        break;
                    case LastModified:
                        if (!isOsmTileNewer(fileAge)) {
                            log.finest("LastModified test: local version is up to date: " + tile);
                            tile.setLoaded(true);
                            tileFile.setLastModified(System.currentTimeMillis() - maxCacheFileAge + recheckAfter);
                            return;
                        }
                        break;
                    }
                }
                if (source.getTileUpdate() == TileUpdate.ETag || source.getTileUpdate() == TileUpdate.IfNoneMatch) {
                    if (tileFile != null) {
                        String fileETag = loadETagfromFile();
                        if (fileETag != null) {
                            switch (source.getTileUpdate()) {
                            case IfNoneMatch:
                                urlConn.addRequestProperty("If-None-Match", fileETag);
                                break;
                            case ETag:
                                if (hasOsmTileETag(fileETag)) {
                                    tile.setLoaded(true);
                                    tileFile.setLastModified(System.currentTimeMillis() - maxCacheFileAge
                                            + recheckAfter);
                                    return;
                                }
                            }
                        }
                    }

                    String eTag = urlConn.getHeaderField("ETag");
                    saveETagToFile(eTag);
                }
                if (urlConn.getResponseCode() == 304) {
                    // If we are isModifiedSince or If-None-Match has been set
                    // and the server answers with a HTTP 304 = "Not Modified"
                    log.finest("ETag test: local version is up to date: " + tile);
                    tile.setLoaded(true);
                    tileFile.setLastModified(System.currentTimeMillis() - maxCacheFileAge + recheckAfter);
                    return;
                }

                byte[] buffer = loadTileInBuffer(urlConn);
                if (buffer != null) {
                    tile.loadImage(new ByteArrayInputStream(buffer));
                    tile.setLoaded(true);
                    listener.tileLoadingFinished(tile, true);
                    saveTileToFile(buffer);
                } else {
                    tile.setLoaded(true);
                }
            } catch (Exception e) {
                tile.setImage(Tile.ERROR_IMAGE);
                listener.tileLoadingFinished(tile, false);
                if (input == null) {
                    System.err.println("failed loading " + zoom + "/" + tilex + "/" + tiley + " " + e.getMessage());
                }
            } finally {
                tile.loading = false;
                tile.setLoaded(true);
            }
        }

        protected boolean loadTileFromFile() {
            FileInputStream fin = null;
            try {
                tileFile = getTileFile();
                fin = new FileInputStream(tileFile);
                if (fin.available() == 0)
                    throw new IOException("File empty");
                tile.loadImage(fin);
                fin.close();
                fileAge = tileFile.lastModified();
                boolean oldTile = System.currentTimeMillis() - fileAge > maxCacheFileAge;
                // System.out.println("Loaded from file: " + tile);
                if (!oldTile) {
                    tile.setLoaded(true);
                    listener.tileLoadingFinished(tile, true);
                    fileTilePainted = true;
                    return true;
                }
                listener.tileLoadingFinished(tile, true);
                fileTilePainted = true;
            } catch (Exception e) {
                try {
                    if (fin != null) {
                        fin.close();
                        tileFile.delete();
                    }
                } catch (Exception e1) {
                }
                tileFile = null;
                fileAge = 0;
            }
            return false;
        }

        protected byte[] loadTileInBuffer(URLConnection urlConn) throws IOException {
            input = urlConn.getInputStream();
            ByteArrayOutputStream bout = new ByteArrayOutputStream(input.available());
            byte[] buffer = new byte[2048];
            boolean finished = false;
            do {
                int read = input.read(buffer);
                if (read >= 0) {
                    bout.write(buffer, 0, read);
                } else {
                    finished = true;
                }
            } while (!finished);
            if (bout.size() == 0)
                return null;
            return bout.toByteArray();
        }

        /**
         * Performs a <code>HEAD</code> request for retrieving the
         * <code>LastModified</code> header value.
         * 
         * Note: This does only work with servers providing the
         * <code>LastModified</code> header:
         * <ul>
         * <li>{@link OsmTileLoader#MAP_OSMA} - supported</li>
         * <li>{@link OsmTileLoader#MAP_MAPNIK} - not supported</li>
         * </ul>
         * 
         * @param fileAge
         * @return <code>true</code> if the tile on the server is newer than the
         *         file
         * @throws IOException
         */
        protected boolean isOsmTileNewer(long fileAge) throws IOException {
            URL url;
            url = new URL(tile.getUri());
            HttpURLConnection urlConn = (HttpURLConnection) url.openConnection();
            prepareHttpUrlConnection(urlConn);
            urlConn.setRequestMethod("HEAD");
            urlConn.setReadTimeout(30000); // 30 seconds read timeout
            // System.out.println("Tile age: " + new
            // Date(urlConn.getLastModified()) + " / "
            // + new Date(fileAge));
            long lastModified = urlConn.getLastModified();
            if (lastModified == 0)
                return true; // no LastModified time returned
            return (lastModified > fileAge);
        }

        protected boolean hasOsmTileETag(String eTag) throws IOException {
            URL url;
            url = new URL(tile.getUri());
            HttpURLConnection urlConn = (HttpURLConnection) url.openConnection();
            prepareHttpUrlConnection(urlConn);
            urlConn.setRequestMethod("HEAD");
            urlConn.setReadTimeout(30000); // 30 seconds read timeout
            // System.out.println("Tile age: " + new
            // Date(urlConn.getLastModified()) + " / "
            // + new Date(fileAge));
            String osmETag = urlConn.getHeaderField("ETag");
            if (osmETag == null)
                return true;
            return (osmETag.equals(eTag));
        }

        protected File getTileFile() {
            return new File(tileCacheDir + "/" + tile.getZoom() + "_" + tile.getXtile() + "_" + tile.getYtile() + "."
                    + source.getTileType());
        }

        protected void saveTileToFile(byte[] rawData) {
            try {
                FileOutputStream f = new FileOutputStream(tileCacheDir + "/" + tile.getZoom() + "_" + tile.getXtile()
                        + "_" + tile.getYtile() + "." + source.getTileType());
                f.write(rawData);
                f.close();
                // System.out.println("Saved tile to file: " + tile);
            } catch (Exception e) {
                System.err.println("Failed to save tile content: " + e.getLocalizedMessage());
            }
        }

        protected void saveETagToFile(String eTag) {
            try {
                FileOutputStream f = new FileOutputStream(tileCacheDir + "/" + tile.getZoom() + "_" + tile.getXtile()
                        + "_" + tile.getYtile() + ETAG_FILE_EXT);
                f.write(eTag.getBytes(ETAG_CHARSET.name()));
                f.close();
            } catch (Exception e) {
                System.err.println("Failed to save ETag: " + e.getLocalizedMessage());
            }
        }

        protected String loadETagfromFile() {
            try {
                FileInputStream f = new FileInputStream(tileCacheDir + "/" + tile.getZoom() + "_" + tile.getXtile()
                        + "_" + tile.getYtile() + ETAG_FILE_EXT);
                byte[] buf = new byte[f.available()];
                f.read(buf);
                f.close();
                return new String(buf, ETAG_CHARSET.name());
            } catch (Exception e) {
                return null;
            }
        }

    }

    public long getMaxFileAge() {
        return maxCacheFileAge;
    }

    /**
     * Sets the maximum age of the local cached tile in the file system. If a
     * local tile is older than the specified file age
     * {@link OsmFileCacheTileLoader} will connect to the tile server and check
     * if a newer tile is available using the mechanism specified for the
     * selected tile source/server.
     * 
     * @param maxFileAge
     *            maximum age in milliseconds
     * @see #FILE_AGE_ONE_DAY
     * @see #FILE_AGE_ONE_WEEK
     * @see TileSource#getTileUpdate()
     */
    public void setCacheMaxFileAge(long maxFileAge) {
        this.maxCacheFileAge = maxFileAge;
    }

    public String getCacheDirBase() {
        return cacheDirBase;
    }

    public void setTileCacheDir(String tileCacheDir) {
        File dir = new File(tileCacheDir);
        dir.mkdirs();
        this.cacheDirBase = dir.getAbsolutePath();
    }

}
